Skip to content

experiment: Swift-side per-class identity cache with shared memory flag#5

Closed
krodak wants to merge 16 commits intomainfrom
experiment/swift-weak-set-fork
Closed

experiment: Swift-side per-class identity cache with shared memory flag#5
krodak wants to merge 16 commits intomainfrom
experiment/swift-weak-set-fork

Conversation

@krodak
Copy link
Copy Markdown
Collaborator

@krodak krodak commented Apr 22, 2026

Overview

Experimental Swift-side identity cache as an alternative to the JS-side deinit(pointer) call on cache hits. Builds on top of PRs #2 and #3.

Currently, when the same Swift pointer is returned and the JS identity cache hits, JS calls deinit(pointer) back into WASM to balance the passRetained that Swift did. This PR moves the "already exported?" tracking to Swift, allowing the thunk to skip passRetained entirely on cache hits.

How it works

Each identity-mode class gets a generated Set<UnsafeMutableRawPointer> tracking exported pointers. The generated thunk checks the Set before returning:

return withExtendedLifetime(ret) {
    let pointer = Unmanaged.passUnretained(ret).toOpaque()
    if _MyModel_identityExported.contains(pointer) {
        _swift_js_set_identity_ref(1)  // tell JS: not retained
        return pointer
    }
    _MyModel_identityExported.insert(pointer)
    _ = Unmanaged.passRetained(ret)
    return pointer
}

Swift signals JS via @_extern(wasm, module: "bjs", name: "swift_js_set_identity_ref") — the same pattern as _swift_js_push_i32 and other existing BridgeJS intrinsics. No DataView, no shared memory buffer management.

JS checks the signal before calling __construct. On cache hit with signal set, JS skips deinit. On race condition (stale JS cache), JS calls bjs_identity_retain to recover.

The per-class Set and signal are implementation details in the generated code — no changes to user-facing @JS API.

What changed

  • BridgeJSIntrinsics.swift — Added _swift_js_set_identity_ref JS import (same pattern as _swift_js_push_i32). Added bjs_identity_retain WASM export for race recovery. Removed DataView-based shared memory flag.
  • ExportSwift.swift — Identity-mode thunks use withExtendedLifetime + passUnretained + Set check. Per-class Set<UnsafeMutableRawPointer> generated alongside thunks. Deinit cleans up Set entry.
  • BridgeJSLink.swift — JS import handler for swift_js_set_identity_ref. Modified __wrap cache hit/miss paths to check signal. Removed DataView infrastructure.

Benchmark comparison

Release build, adaptive sampling:

Scenario JS-only cache (PRs #2+#3) Swift-side Set (this) Change
passBothWaysRoundtrip (1M) 55 ms 84 ms +53% slower
getPoolRepeated_100 (1M) 90 ms 106 ms +18% slower
swiftCreatesObject (1M) 3048 ms 2337 ms -23% faster
churnObjects (100k) 162 ms ~9800 ms much slower (Set grows)

Analysis

The Swift-side approach trades cache-hit speed for create-path improvement. Set.contains with SipHash on WASM costs ~20-30ns per call (no hardware-accelerated hashing), making the cache-hit path slower than a single deinit WASM call (~4-8ns after V8 JIT optimization).

The churnObjects regression is severe — objects are created, crossed, and released in a tight loop. The per-class Set grows because FinalizationRegistry cleanup is asynchronous. This is a known limitation of the per-class Set approach.

When this approach wins

  • Create-heavy workloads with identity-mode classes (fewer retain/release cycles on first crossing)

When JS-only cache wins

  • Reuse-heavy workloads (tighter hot loop, no hash overhead)
  • Churn workloads (Set grows unboundedly until GC fires)
  • Bulk array returns

krodak added 16 commits April 17, 2026 11:17
…ntity caching

Add identityMode: "pointer" option to BridgeJS instantiation. When enabled,
a WeakRef-based identity cache keyed by pointer ensures the same Swift heap
pointer returns the same JS wrapper (=== equality).

Each class gets its own FinalizationRegistry and identity cache stored on the
deinit function. Off by default, zero overhead when not enabled.
Replace per-class FinalizationRegistry instances with a single shared
registry at module level. Move identity cache from deinit function property
to per-class static __identityCache field. Cleaner codegen, fewer
allocations, easier to inspect in DevTools.
…ence

Each boundary crossing calls passRetained on the Swift side. On cache hit,
the wrapper is returned without creating a new FinalizationRegistry entry,
leaving the retain unbalanced. Call deinit(pointer) on cache hit to
immediately release the extra retain.

Also fix deinit reference for namespaced classes to use abiName instead
of short class name.
Add Tests/BridgeJSRuntimeTests/IdentityModeSupportTests module covering:
- Wrapper identity for shared Swift objects
- Cache invalidation on release
- Different classes don't collide on same pointer
- Retain leak regression test for cache hits
- Array identity preservation

Wire IDENTITY_MODE env var through prelude.mjs to toggle instantiateOptions.
Add unittest-pointer Makefile target for running tests with identityMode:
"pointer".
Restore the no-op polyfill pattern for environments without FinalizationRegistry
instead of null, matching the upstream convention. Remove finalizer parameter
from makeFresh since the polyfill is always callable. Use has() guard before
stale WeakRef cleanup. Remove formatting-only changes from instantiate.d.ts.
…ToGlobal pattern

Add identityMode field to BridgeJSConfig, flow through SwiftToSkeleton and
ExportedSkeleton to BridgeJSLink. Generated JS uses config value as default
with runtime option as override via nullish coalescing.

Create dedicated BridgeJSIdentityTests target with identityMode: pointer in
its bridge-js.config.json. Remove IDENTITY_MODE env var, instantiateOptions
spread from prelude.mjs, and unittest-pointer Makefile target. Identity tests
now run as part of the normal test suite.
…x.js

TypeScript strict excess property check rejects identityMode in the spread
into DefaultNodeSetupOptions. Destructure it out before spreading, since
it's already handled separately via the instantiateOptions pass-through.
…generate script

The Generated files were incorrectly copied from BridgeJSRuntimeTests,
containing types from the wrong module. Regenerate with BridgeJSTool for
the BridgeJSIdentityTests target. Add target to bridge-js-generate.sh.
Fix SwiftToSkeleton formatting.
When multiple targets share one createInstantiator (e.g. test package),
use compactMap to find the first non-nil identityMode across all skeletons
instead of reading from the first skeleton which may not have it set.
Extend run.js with --identity-mode, --identity-iterations, --identity-reuse-pools,
and --identity-memory CLI flags. Extract identity scenarios into
lib/identity-benchmarks.js: roundtrip reuse, bulk pool return (100 cached
objects), churn (create-roundtrip-release), consume, and create paths.
Memory telemetry via --identity-memory.

Update README.md with identity mode flags and scenario descriptions.
Add IdentityCacheBenchmark with setupPool/getPoolRepeated for bulk array
return scenarios. Update generated BridgeJS bindings for benchmark target.
Benchmark results can be noisy due to GC timing and V8 JIT compilation.
IQR filtering discards values outside Q1-1.5*IQR to Q3+1.5*IQR before
computing statistics. The Samples column shows retained count (e.g.
'4 (-1)' means 4 kept, 1 discarded). Falls back to the full dataset
if fewer than 4 samples.

Applies to all benchmarks, not just identity mode.
feat: Add opt-in identityMode pointer for SwiftHeapObject wrapper identity caching
Add identityMode: Bool parameter to @js macro. When set to true on a class,
that class uses pointer identity caching. When false or not set, the class
uses the bridge-js.config.json default.

Identity is resolved entirely at codegen time - no runtime option. Classes
with identity mode get static __identityCache passed to __wrap. Classes
without it pass null. No runtime branching in __construct.

Resolution: @js(identityMode: true/false) > bridge-js.config.json > default (off).
feat: Add per-class identityMode via @js macro parameter
Replace DataView shared memory flag with @_extern(wasm) JS import for
signaling, following the existing BridgeJS intrinsics pattern
(_swift_js_push_i32, _swift_js_return_optional_heap_object, etc).

Per-class Set tracks exported pointers. On cache hit, thunk calls
_swift_js_set_identity_ref(1) and returns passUnretained. JS checks
the ref and skips deinit.

Trade-off vs JS-only cache: Set.contains with SipHash on WASM adds
~20-30ns per crossing, exceeding the ~4-8ns deinit WASM call it
replaces. Improves create-heavy paths by ~20% but regresses
roundtrip by ~50%.
@krodak krodak force-pushed the experiment/swift-weak-set-fork branch from 444417f to d632591 Compare April 22, 2026 16:49
@krodak krodak closed this Apr 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant